ItIron2023 react我們昨天看了一個看似useEffect在搞事的問題,雖然說不能與它完全無關,但實際上他確實挺無辜的,真要怪的話我們就怪react為什麼要這樣設計吧! 今天我們另外再來看個也是會在useEffect時碰上的問題,這類的情境往往發生在data fetch的時候,馬上來看一下今天的題目吧!
首先一樣請你看一下今天的codesandbox,要稍微耐著點性子,這次題目較長一些。

今天的例子就稍微比較複雜一些,首先我們有個下拉的選單讓你去選取你想fetch哪個userId的資料,當你下好離手後便會開始fetch需要的資料,資料請求完後會得到一個userData,病在下方顯示這次請求到userData中的name值,若你今天最終顯示的名字與發出請求時的userId有對應到,那麼便會顯示綠色字體,反之則顯示紅色。
請求的過程我塞了一些邏輯,這個部分你可以暫時忽略,都是為了觸發我接著要描述的情況。
乍看之下這份程式碼並沒有什麼大問題,每次點選後並等待一段時間後,最終顯示的名字與userId都是有匹配上的。
但若是你今天按照以下的操作,如以下的gif所示

你會發現有趣的事情,最終的畫面是停在這

我明明是選user3,但最後出來的名字卻是屬於user1的,請觀察以下的程式碼並試著修復此問題。
function sleep(delay) {
  return new Promise((resolve) => {
    setTimeout(() => resolve(), delay);
  });
}
const expectedUserMapping = {
  "1": "Leanne Graham",
  "2": "Ervin Howell",
  "3": "Clementine Bauch"
};
export default function UserProfile() {
  const [userId, setUserId] = useState(1);
  const [userData, setUserData] = useState(null);
  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch(
        `https://jsonplaceholder.typicode.com/users/${userId}`
      );
      const result = await response.json();
      // plz forgive me for writing this, it's just for demo
      await sleep(userId === 1 ? 5000 : userId === 2 ? 3000 : 1000);
      setUserData(result);
    };
    fetchData();
  }, [userId]);
  return (
    <div>
      <h1>Race condition with useEffect</h1>
      <select onChange={(e) => setUserId(e.target.value)} value={userId}>
        <option value={1}>User 1</option>
        <option value={2}>User 2</option>
        <option value={3}>User 3</option>
      </select>
      {userData ? (
        <h2
          style={{
            color:
              userData.name === expectedUserMapping[userId] ? "green" : "red"
          }}
        >{`User Name: ${userData.name}`}</h2>
      ) : (
        "Loading..."
      )}
    </div>
  );
}
這也是個相當值得玩味的問題,實際上這跟react並沒有太直接相關,單純是js promise常出現的race condition,只是在不使用三方套件的情況下大家多半會把fetch請求放在useEffect中,因此這個問題也是經常在各大影片或是教學中會出現的範例之一。
今天題目你已經知道是race condition了,那麼你只要針對這點下手即可,我們希望當我們切換userId時發出新請求,同時在重新渲染前阻止前一個發送的請求去更新我們的userData狀態,這類的情況就是我們之前提過cleanup function該出手的時機了,主流上有三種解決的方式,我這邊先列出其中兩種。
這個方法相對直觀一些,我們建立一個flag來決定是否發出的請求是否要更動最終的狀態,若在請求完成前我們就重新渲染便會利用cleanup function將isCancelled設為true,這麼一來在之前的請求完成後就不會更新state,僅有最後一個發出的請求會去更新state。
useEffect(() => {
  let isCancelled = false;
  const fetchData = async () => {
    const response = await fetch(
      `https://jsonplaceholder.typicode.com/users/${userId}`
    );
    const result = await response.json();
    await sleep(userId === 1 ? 7000 : userId === 2 ? 3000 : 1000);
    if (!isCancelled) {
      setUserData(result);
    }
  };
  fetchData();
  return () => {
    isCancelled = true;
  };
}, [userId]);
這個方法理論上是更為理想的,原因在於我們並不僅是阻止update state的行為,而是連不必要的請求也一併取消了,AbortController可以建立一個實體,並利用signal去追蹤目前fetch或是dom操作這類的非同步行為,給予你在必要的時候終止操作的選項,下方便是一個基本的範例,不過由於範例中用的api請求極為快速,為了讓他有機會去取消request,你必須在瀏覽器動點手腳,我建議將瀏覽器請求限制為slow 3G來看最終的效果。

useEffect(() => {
  const abortController = new AbortController();
  const fetchData = async () => {
    try {
      const response = await fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, { signal: abortController.signal });
      const result = await response.json();
      await sleep(userId === 1 ? 7000 : userId === 2 ? 3000 : 1000);
      setUserData(result);
    } catch (error) {
      if (error.name !== 'AbortError') {
        console.error('Fetch error:', error);
      }
    }
  };
  fetchData();
  return () => {
    abortController.abort();
  };
}, [userId]);
這可不是幹話,有不少團隊在實務上是將請求利用第三方套件完全抽離的,像是swr或是react-query都是常見的選擇,讓套件替你去處理這類的race condition以及一些cache的問題。
我們今天看了一個相對進階一些的範例,需要用到你之前幾天學到的知識才有可能正確解出,至於AbortController或是第三方套件的使用這就交給你自己去研究了,當然你還存在著其他的做法,比方說利用一些行為去限制請求的發生,確保這類的情況不會出現!至於哪一種最好呢...就交給你自己判斷囉,給你一些時間消化,我們明天見吧!
本文章同步發布於個人部落格,有興趣的朋友也可以來逛逛~!